Skip to content

fix(app): stop expanded rows corrupting virtualized row heights - #3171

Merged
kodiakhq[bot] merged 2 commits into
mainfrom
cursor/fix-virtualized-row-height-d7eb
Sep 22, 2026
Merged

kodiakhq[bot] merged 2 commits into
mainfrom
cursor/fix-virtualized-row-height-d7eb

Conversation

@MikeShi42

@MikeShi42 MikeShi42 commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Expanding and then collapsing rows in the search results table permanently shrinks the virtualizer's render window. After a few expand/collapse cycles, scrolling leaves a screen of empty space with only a handful of rows in it — sometimes none at all, while the result count still reads 127.

Root cause. A row and its inline expansion are two <tr>s but one virtual item. Both carried data-index={virtualRow.index} and the virtualizer's measureElement ref:

  • virtual-core keys measureElementCache by item.key, which is the index. When the expanded <tr> mounts, _measureElement sees prevNode !== node, unobserves the base row, observes the expanded row, and caches the expanded row's height as the size of that index.
  • Collapsing calls the ref with null, and measureElement returns early for null. The ResizeObserver does fire for the detached node, but that path takes the !node.isConnected branch, which unobserves and returns before resizing.

So the index keeps the expanded height forever, and nothing observes that row any more, so it never re-measures. Every expand/collapse inflates one more index.

Fix. Wrap each row and its expansion in their own <tbody> and measure that instead, so one DOM element maps to one virtual index. The ResizeObserver on the <tbody> picks up both the row and the expansion growing or going away, so collapsing restores the correct height on its own. No stylesheet targets this table's tbody, and multiple tbody elements are valid in a table.

This is not a new regression — it reproduces on main today via the chevron. The expand/collapse affordance just makes it easy to hit.

Screenshots or video

Six expand/collapse cycles on rows 0–5, then scrolling to the same offset. Left is main, right is this branch:

Side by side: on main the search results table is blank below the column headers, on this branch it is full of log rows

Measured on the scroll container during that run:

rows rendered space below last row
baseline, nothing expanded 55 −711px
after 6 expand/collapse, before fix 32 +941px at scrollTop 1200
after 6 expand/collapse, with fix 55 −686px at scrollTop 1200

Negative means the rendered rows overfill the viewport, which is what you want. The viewport is 555px tall, so a +941px gap means nothing at all is visible.

Tests

packages/app/tests/e2e/features/search/virtualized-row-heights.spec.ts covers both halves: that collapsing restores the render window, and that scrolling afterwards keeps the viewport covered. Both fail on main (Expected: 55, Received: 38; Expected: <= 0, Received: 86) and pass here.

make ci-lint and make ci-unit are green. The full search/ E2E directory was re-run: 84 passed, 3 flaky-then-passed, and 2 search-filters.spec.ts failures that reproduce identically with packages/app/src reverted to origin/main, so they are environmental rather than from this change.

How to test on Vercel preview

Preview routes: /search

Steps:

  1. Wait for the results table to render rows.
  2. Click the chevron at the left of the first row to expand it, then click it again to collapse it.
  3. Repeat step 2 on the next five rows.
  4. Scroll the results table down roughly one screen.
  5. Verify the table is still full of log rows, with no blank area between the last row and the bottom of the table.

References

Linear Issue: HDX-5365

Open in Web Open in Cursor 

A row and its inline expansion are two `tr`s sharing one virtual index, and
both carried `data-index` and the virtualizer's `measureElement` ref. Since
virtual-core keys its element cache by index, the expanded `tr` took over that
index's ResizeObserver registration and cached its own height there. Collapsing
called the ref with `null`, which `measureElement` ignores, so the inflated
height stuck and nothing observed the row any more.

Each expand/collapse then shrank the render window until scrolling showed a few
rows above a screen of empty space. Wrap the pair in a `tbody` and measure that
instead, so one element maps to one virtual index.

Co-authored-by: Mike Shi <mike@hyperdx.io>
@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Sep 22, 2026 12:56pm UTC
hyperdx-storybook Ready Ready Preview Sep 22, 2026 12:56pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 970bd3c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with the measurement ownership fix and regression coverage aligned with the reported failure.

Summary

Fixes stale virtualized row heights after inline log details are expanded and collapsed.

  • Measures each base row and optional expansion as one tbody virtual item.
  • Removes competing virtualizer measurement refs from expanded rows.
  • Adds Playwright coverage for restored row counts and viewport coverage after scrolling.
  • Adds a patch changeset describing the user-facing fix.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  V[Virtual item] --> B[Measured tbody]
  B --> R[Base log row]
  B --> E{Expanded?}
  E -->|Yes| X[Inline details row]
  E -->|No| R
  B --> M[ResizeObserver measurement]
  M --> C[Virtualizer size cache]
Loading

Reviews (2) · Last reviewed commit: "Merge branch 'main' into cursor/fix-virt..."

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Results

✅ All tests passed • 381 passed • 1 skipped • 1124s

Status Count
✅ Passed 381
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

@MikeShi42
MikeShi42 marked this pull request as ready for review September 22, 2026 06:17
@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label Sep 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 312 production lines changed (Tier 2 max: < 250)

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 2
  • Production lines changed: 312 (+ 109 in test files, excluded from tier calculation)
  • Branch: cursor/fix-virtualized-row-height-d7eb
  • Author: MikeShi42

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. This is a well-contained, correctly-reasoned virtualization fix. Moving data-index + the @tanstack/react-virtual measureElement ref off the two per-row <tr>s and onto a single per-row <tbody> gives one DOM element per virtual index, so the base row and its inline expansion no longer fight over one index's ResizeObserver registration. Collapsing now re-measures on its own. Direct analysis confirms the supporting claims: packages/app/styles/LogTable.module.scss targets .tableRow/.expandedRow by class (not by tr), has no tbody or nth-child/nth-of-type selectors, and uses table-layout: fixed with border-collapse: separate, so per-row <tbody> grouping does not disturb column widths, striping, hover, or the sticky header. The removed measureElement/virtualIndex props on ExpandedLogRow have no remaining callers.

🟡 P2 — recommended

None.

🔵 P3 nitpicks (2)
  • packages/app/tests/e2e/features/search/virtualized-row-heights.spec.ts:20 — Coverage only exercises the chevron (collapseRow/expandRow) path; the row-content-button onClick toggle (_onRowExpandClick) that drives the same expansion state is not exercised.
    • Fix: Add a case that toggles expansion via the row-content click path to guard the other entry point into the same state.
  • packages/app/tests/e2e/features/search/virtualized-row-heights.spec.ts:34 — The .poll(...).toBe(before.renderedRows) exact-equality assertion and the remainingBelowPx > 0 guard both assume a fixed 1280×900 viewport and a large empty-search dataset; a smaller seeded dataset or a change to overscan/row-height estimation could make the equality off-by-one or make the scroll test pass vacuously.
    • Fix: Assert a tolerance band (or >= a floor) on renderedRows and assert the dataset is large enough to keep rows below the fold before checking visibleGapPx.

Reviewers: orchestrator direct diff analysis, performance, testing (both returned clean). Also dispatched but not yet returned at synthesis time: correctness, adversarial, frontend-races, kieran-typescript, maintainability, project-standards, agent-native, learnings-researcher, previous-comments. Prior PR comments were all automated (Vercel, changeset-bot, Greptile 5/5, passing E2E) with no actionable human feedback to verify.

Testing gaps: Regression protection is entirely behavioral (e2e); no component-level test pins the one-<tbody>-per-virtual-index structural invariant, and only the chevron expand/collapse path is covered.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

.poll(async () => {
const m = await searchPage.table.getVirtualizationMetrics();
// Rows still below, so the rendered window must cover the viewport.
return m.remainingBelowPx > 0 ? m.visibleGapPx : 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — remainingBelowPx > 0 guard lets the gap assertion pass vacuously

remainingBelowPx is scrollHeight - scrollTop - clientHeight, which counts the footer <tbody> (the "End of Results"/loading banner, DBRowTable.tsx:1293-1390) and the virtualizer's paddingEnd: 20 as content below — it does not mean "rows still below". If the seeded search returns few enough rows that the table doesn't overflow, scrollTo(1200) clamps to 0, remainingBelowPx is 0, and all three offsets return 0 and pass without measuring anything; conversely an offset landing inside the footer band makes visibleGapPx positive on a correctly rendered table. Assert a precondition up front (e.g. remainingBelowPx > 0 and renderedRows > 20 before the loop, as test 1 does) and derive the offsets from the measured scroll range rather than hardcoding 300/700/1200.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

// expand/collapse shrank the render window until the viewport went blank.
test('collapsing an expanded row restores the table height', async () => {
const before = await searchPage.table.getVirtualizationMetrics();
expect(before.renderedRows).toBeGreaterThan(20);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Exact-equality baseline captured before the virtualizer settles

before is read immediately after the first row becomes visible, then used as an exact expected value at line 35. Row heights are still settling at that point — the table renders in var(--font-ibm-plex-mono) (packages/app/styles/LogTable.module.scss:24), so a font swap after the baseline reading re-measures every row and shifts the steady-state window size by a row or two, and the poll at line 30 then times out on a healthy table. Poll the baseline until two consecutive readings agree (or assert visibleGapPx <= 0 / renderedRows within a tolerance) instead of pinning an exact count taken from the first paint.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

// the element registered for this row's virtual index.
<tr
data-testid={`expanded-row-${rowId}`}
key={`${virtualKey}-expanded`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — virtualKey prop is now dead

With measureElement/virtualIndex gone, virtualKey's only remaining use is key={${virtualKey}-expanded} on the component's single root element, which has no reconciliation effect — the key that matters is on the <tbody> at DBRowTable.tsx:1153. Drop the prop and the virtualKey={virtualRow.key.toString()} argument at DBRowTable.tsx:1278.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

3 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 3 minor

3 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@pulpdrew pulpdrew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kodiakhq
kodiakhq Bot merged commit 4fa3c37 into main Sep 22, 2026
28 checks passed
kodiakhq Bot pushed a commit that referenced this pull request Sep 29, 2026
…3117)

Resolves HDX-5365.

> **Stacked on #3171.** Base is `cursor/fix-virtualized-row-height-d7eb`; it will retarget to `main` once that merges. See "Virtualized row heights" below.

## Why

The only way to expand a search result row inline is the 16px chevron, which is a poor hit target when you are scanning logs during an incident. Comparable tools expand on row click.

Rather than change what a row click does for everyone, this adds a preference. **The default is unchanged** — a row click still opens the side panel. Users who want the bigger hit target can opt in.

## What changed

**Preferences → Search results → Row click**, defaulting to `Open side panel`. Set it to `Expand inline` and:

- a row click toggles inline expansion instead of opening the side panel;
- the side panel moves to a hover button on the row (`Open in side panel`, beside the existing copy buttons), and the expanded row's maximize button still opens it;
- row clicks keep driving the side panel while it is open, so browsing row to row inside the panel is unchanged. Closing the panel returns clicks to inline expansion.

Stored as an optional `rowClickAction`. Stored preferences that predate the field are returned as-is rather than merged with the defaults, so readers fall back to `DEFAULT_ROW_CLICK_ACTION`.

Two table variants need care regardless of the preference:

- Tables that hide the expand column (surrounding context, patterns) have nowhere to put inline details, so they always open the side panel.
- The ClickHouse dashboard's slowest-queries list renders inline details but passes no `onRowDetailsClick`, so it always expands inline and offers neither the hover button nor the expanded row's maximize button — both would only write URL params nothing reads.

Routing a row click needs to know whether a side panel is available and whether one is already open, and two signals did not actually mean that:

- `highlightedLineId` was passed to the table even when the panel was not mounted. `rowWhere`/`rowSource` survive a source switch while closing the panel clears both, so a stale `rowWhere` left the table highlighting a row with no panel on screen. The panel is now hoisted into one `sidePanel` element that both its own mount and `highlightedLineId` derive from.
- `ExpandedLogRow` gated its maximize button on `{openSidebar && …}`, the return value of a `useCallback` and so always truthy. It now takes `canOpenSidePanel`.

`DBRowTableIconButton` also gained an `aria-label` from its title, which the row hover buttons had been missing.

## Virtualized row heights

Reported against this branch: expand and collapse a few rows, scroll, and the table goes blank under the header.

That bug is on `main` today — it reproduces identically through the chevron, with this PR's `packages/app/src` reverted. A row and its inline expansion are two `tr`s sharing one virtual index, and both were measured, so the expanded one took over that index's `ResizeObserver` registration and left its height cached there after collapsing. Every expand/collapse shrank the render window.

Fixed separately in **#3171** so it lands for chevron users regardless of this PR, and merged in here so this branch does not ship the bigger hit target on top of it. That merge is the only reason `DBRowTable.tsx` now renders a `tbody` per row.

## Tests

- Seven unit tests in `DBRowTable.test.tsx`: the side panel on row click by default (and no redundant hover button in that mode), then under `expand` — inline expand/collapse on body click, the hover button, `showExpandButton={false}`, and the open-panel case; plus the no-side-panel table expanding inline regardless of the preference. These are the first tests in that file to render virtualized body rows, hence the geometry mocks.
- The inline-expand test also asserts the expanded row exposes `Open in side panel` and that clicking it writes the `rowWhere` param, so `canOpenSidePanel` is pinned in both directions — hardcoding it to `false` fails the test rather than staying green.
- `UserPreferencesModal.test.tsx` drives the select: the default for users with no stored preference, a stored value round-tripping into the control, and picking an option writing `rowClickAction`. A wrong storage key or option value now fails instead of passing green.
- E2E: the specs that reach the side panel go back to a plain row click, since that is the default again. A new `Row click set to expand inline` describe seeds the preference before navigating and asserts a row body click expands inline without opening the panel, collapses on a second click, and that the hover button opens the panel.
- `virtualized-row-heights.spec.ts` comes in from #3171 and covers the virtualizer regression.

## Rebase onto `main`

Rebased past #3140 and #3164, which added row multi-select and hover-revealed selection checkboxes to the same row component. No textual conflicts, and the two features compose: the checkbox lives in its own cell and stops propagation, so it does not compete with the row-body click.

CI lint was failing only because this branch predated the `--max-warnings` bump on `main`. The rebase picks that up; the one warning this PR did add (an unsafe cast in the new `Select` handler) is gone, so the app count is back under the ceiling rather than exactly on it.

Verification: `make ci-lint` and `make ci-unit` green (244 suites, 3948 tests after the merge). All 94 `search/` E2E tests re-run — 85 passed, 1 flaky-then-passed, and 2 `search-filters.spec.ts` failures that reproduce identically with this PR's `packages/app/src` reverted to `origin/main`, so they are environmental and not from this change. `correlated-metric-source`, `span-links` and `traces-workflow` also pass.

## Walkthrough

The reported repro, end to end on this branch: two row clicks opening the side panel on the default setting, switching the preference to `Expand inline`, expanding and collapsing six rows, then scrolling — the table stays full.

<a href="https://cursor.com/agents/bc-b2c91bdd-e466-447c-942d-dce15159d7eb/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fvirtualized_scroll_repro_fixed.mp4"><img src="https://cursor.com/artifacts/c/art-27481ff4-27fa-4bca-bbe1-3c2abee4f889" alt="virtualized_scroll_repro_fixed.mp4" /></a>

Default — a row click opens the side panel, unchanged:

![Log search with the row side panel open showing Overview, Column Values, Surrounding Context and Infrastructure tabs](https://cursor.com/artifacts/c/art-5932460f-fe67-497d-a59e-71d72be3bb16)

The opt-in:

![Preferences modal showing the Search results section with Row click set to Expand inline](https://cursor.com/artifacts/c/art-3426a775-142f-4fd0-a69f-796378278862)

With it on, a row click expands in place, with no side panel:

![A result row expanded inline showing its Column Values tab, with the rest of the table still visible below](https://cursor.com/artifacts/c/art-59069505-4579-4dab-828c-4a6c316d83f9)

Hovering a row shows both features side by side — the selection checkbox on the left, and this PR's `Open in side panel` button among the row action icons on the right:

![A hovered result row showing the multi-select checkbox at the left and the row action icons including Open in side panel at the right](https://cursor.com/artifacts/c/art-5977cdc5-af08-4768-bd8d-20de43e7655a)



Linear Issue: [HDX-5365](https://linear.app/clickhouse/issue/HDX-5365/expand-log-inline-on-row-click)

<div><a href="https://cursor.com/agents/bc-b2c91bdd-e466-447c-942d-dce15159d7eb?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-b2c91bdd-e466-447c-942d-dce15159d7eb&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>



Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>

This branch was successfully deployed

2 active deployments
Preview – hyperdx-oss — 970bd3cc Deployed Sep 22, 2026 by vercel[bot]
Preview – hyperdx-storybook — 970bd3cc Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants